1"use client";
2
3
18import { useParams } from "next/navigation";
19import { useQuery } from "@tanstack/react-query";
20import { apiFetch, type ApiError } from "@/lib/api";
21import { valueToText, type DatabaseProperty, type RecordValue } from "@/hooks/use-database";
22import { formatRelativeTime } from "@/lib/utils";
23
24
25interface ShareBlock {
26 id: string;
27 type: string;
28 content: unknown;
29 properties: Record<string, unknown>;
30 position: string;
31}
32interface ShareData {
33 share: { id: string; scope: string; allowComments: boolean; expiresAt: string | null };
34 workspace: { id: string; name: string; slug: string };
35 canComment: boolean;
36 target:
37 | { type: "page"; page: { id: string; title: string; icon?: string | null; description?: string | null }; blocks: ShareBlock[] }
38 | {
39 type: "database";
40 database: { id: string; title: string; icon?: string | null; description?: string | null };
41 properties: { id: string; name: string; type: string; isTitle?: boolean }[];
42 records: { id: string; properties: Record<string, unknown> }[];
43 };
44}
45
46
47function extractText(node: unknown, depth = 0): string {
48 if (depth > 12 || node == null) return "";
49 if (typeof node === "string") return node;
50 const n = node as { text?: string; content?: unknown[] };
51 if (typeof n.text === "string") return n.text;
52 if (Array.isArray(n.content)) return n.content.map((c) => extractText(c, depth + 1)).join("");
53 return "";
54}
55
56function sortBlocks(blocks: ShareBlock[]): ShareBlock[] {
57 return [...blocks].sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0));
58}
59
60function ShareBlockView({ block }: { block: ShareBlock }) {
61 const text = extractText(block.content);
62 const props = block.properties ?? {};
63
64 switch (block.type) {
65 case "heading_1":
66 return <h1 className="mt-6 text-3xl font-bold">{text || "\u00A0"}</h1>;
67 case "heading_2":
68 return <h2 className="mt-5 text-2xl font-bold">{text || "\u00A0"}</h2>;
69 case "heading_3":
70 return <h3 className="mt-4 text-xl font-semibold">{text || "\u00A0"}</h3>;
71 case "paragraph":
72 return <p className="my-2 leading-relaxed whitespace-pre-wrap">{text}</p>;
73 case "todo":
74 return (
75 <div className="my-1 flex items-start gap-2">
76 <span
77 className={`mt-1 flex size-4 shrink-0 items-center justify-center rounded border text-[10px] ${
78 props.checked ? "border-emerald-500 bg-emerald-500 text-white" : "border-border"
79 }`}
80 aria-hidden
81 >
82 {props.checked ? "✓" : ""}
83 </span>
84 <span className={`leading-relaxed ${props.checked ? "text-muted-foreground line-through" : ""}`}>{text}</span>
85 </div>
86 );
87 case "bulleted_list":
88 return (
89 <div className="my-1 flex gap-2">
90 <span className="mt-[0.6em] size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden />
91 <span className="leading-relaxed">{text}</span>
92 </div>
93 );
94 case "numbered_list":
95 return (
96 <div className="my-1 flex gap-2">
97 <span className="w-5 shrink-0 text-right text-sm text-muted-foreground" aria-hidden>
98 {(props.number as number) ?? "·"}
99 </span>
100 <span className="leading-relaxed">{text}</span>
101 </div>
102 );
103 case "code":
104 return (
105 <pre className="my-3 overflow-x-auto rounded-md bg-muted p-3 text-sm">
106 <code>{text}</code>
107 </pre>
108 );
109 case "equation": {
110 const latex = (props.latex as string) ?? text;
111 return latex ? <pre className="my-3 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">{latex}</pre> : null;
112 }
113 case "video":
114 case "audio": {
115 const url = (props.url as string) ?? (props.src as string) ?? "";
116 if (!url) return null;
117 return block.type === "video" ? (
118 <video src={url} controls className="my-3 max-w-full rounded-md" />
119 ) : (
120 <audio src={url} controls className="my-3 w-full" />
121 );
122 }
123 case "callout":
124 return (
125 <div className="my-3 flex gap-2 rounded-md border border-border bg-muted/30 p-3 text-sm">
126 <span aria-hidden>{(props.icon as string) ?? "💡"}</span>
127 <div className="leading-relaxed">{text}</div>
128 </div>
129 );
130 case "quote":
131 return <blockquote className="my-3 border-l-2 border-border pl-3 italic text-muted-foreground">{text}</blockquote>;
132 case "divider":
133 return <hr className="my-4 border-border" />;
134 case "image": {
135 const url = (props.url as string) ?? (props.src as string) ?? "";
136 if (!url) return null;
137 return (
138 <img
139 src={url}
140 alt={text || (props.caption as string) || "image"}
141 className="my-3 max-w-full rounded-md"
142 loading="lazy"
143 />
144 );
145 }
146 case "database":
147 case "linked_database":
148 case "child_database":
149 return (
150 <div className="my-3 rounded-md border border-dashed p-3 text-sm text-muted-foreground">
151 A database is embedded here — it isn't included in public shares.
152 </div>
153 );
154 case "spacer": {
155 const height = Math.min(Math.max((props.heightPx as number) ?? 32, 8), 200);
156 return <div style={{ height }} aria-hidden />;
157 }
158 default:
159 return text ? <p className="my-2 leading-relaxed">{text}</p> : null;
160 }
161}
162
163function DatabaseTable({ data }: { data: Extract<ShareData["target"], { type: "database" }> }) {
164 const cols = data.properties;
165 return (
166 <div className="overflow-x-auto rounded-md border border-border">
167 <table className="w-full text-sm">
168 <thead className="border-b border-border bg-muted/40 text-left">
169 <tr>
170 {cols.map((c) => (
171 <th key={c.id} className="whitespace-nowrap px-3 py-2 font-medium">
172 {c.name}
173 </th>
174 ))}
175 </tr>
176 </thead>
177 <tbody>
178 {data.records.length === 0 ? (
179 <tr>
180 <td colSpan={Math.max(cols.length, 1)} className="px-3 py-6 text-center text-muted-foreground">
181 No records.
182 </td>
183 </tr>
184 ) : (
185 data.records.map((r) => (
186 <tr key={r.id} className="border-b border-border last:border-0">
187 {cols.map((c) => {
188 const v = r.properties[c.name];
189 const prop: DatabaseProperty = { id: c.id, name: c.name, type: c.type as DatabaseProperty["type"] };
190 return (
191 <td key={c.id} className="whitespace-nowrap px-3 py-2 align-top">
192 {valueToText(v as RecordValue, prop)}
193 </td>
194 );
195 })}
196 </tr>
197 ))
198 )}
199 </tbody>
200 </table>
201 </div>
202 );
203}
204
205export default function PublicSharePage() {
206 const params = useParams<{ id: string }>();
207 const token = params?.id ?? "";
208
209 const q = useQuery({
210 queryKey: ["public-share", token],
211 queryFn: async (): Promise<ShareData> => {
212 const res = (await apiFetch<ShareData>(`/share/${encodeURIComponent(token)}`, { noAuth: true })) as ShareData;
213 return res;
214 },
215 enabled: token.length > 0,
216 retry: false
217 });
218
219 if (q.isLoading) {
220 return (
221 <div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
222 Loading shared content…
223 </div>
224 );
225 }
226
227 if (q.isError) {
228 const code = q.error instanceof ApiError ? q.error.code : "";
229 return (
230 <div className="flex min-h-screen flex-col items-center justify-center gap-3 px-6 text-center">
231 <div className="text-5xl" aria-hidden>
232 🔒
233 </div>
234 <h1 className="text-xl font-semibold">
235 {code === "NOT_FOUND" ? "This share link is no longer valid" : "Couldn't load this share"}
236 </h1>
237 <p className="max-w-sm text-sm text-muted-foreground">
238 The link may have been revoked, expired, or the content deleted.
239 </p>
240 </div>
241 );
242 }
243
244 const { target, workspace } = q.data;
245
246 return (
247 <main className="min-h-screen bg-background">
248 <div className="mx-auto max-w-3xl px-6 py-10">
249 {target.type === "page" ? (
250 <>
251 <header className="mb-6">
252 <div className="flex items-center gap-2 text-3xl">
253 {target.page.icon ? <span aria-hidden>{target.page.icon}</span> : null}
254 <h1 className="text-3xl font-bold tracking-tight">{target.page.title || "Untitled"}</h1>
255 </div>
256 {target.page.description ? (
257 <p className="mt-1 text-muted-foreground">{target.page.description}</p>
258 ) : null}
259 </header>
260 <article>
261 {sortBlocks(target.blocks).map((b) => (
262 <ShareBlockView key={b.id} block={b} />
263 ))}
264 </article>
265 </>
266 ) : (
267 <>
268 <header className="mb-6">
269 <div className="flex items-center gap-2 text-3xl">
270 {target.database.icon ? <span aria-hidden>{target.database.icon}</span> : null}
271 <h1 className="text-3xl font-bold tracking-tight">{target.database.title || "Database"}</h1>
272 </div>
273 {target.database.description ? (
274 <p className="mt-1 text-muted-foreground">{target.database.description}</p>
275 ) : null}
276 </header>
277 <DatabaseTable data={target} />
278 </>
279 )}
280
281 <footer className="mt-12 border-t border-border pt-4 text-xs text-muted-foreground">
282 Shared from {workspace.name}
283 {q.data.share.expiresAt ? ` · expires ${formatRelativeTime(q.data.share.expiresAt)}` : ""}
284 </footer>
285 </div>
286 </main>
287 );
288}
289